iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Vibe Coding

Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地系列 第 20

Day 20|動態路由與混合渲染實作單一技能詳細頁

  • 分享至 

  • xImage
  •  

在Day19中,我們完成全站UI/UX體驗升級,加入了SonnerToast通知與Modal/ConfirmDialog對話框。

今天(Day20),我們將邁入VibePulse的核心核心功能之一:單一技能詳細頁面/skills/[id]。我們將深入Next.jsAppRouter的靈魂——ServerComponent(RSC)與ClientComponent的混合渲染架構,讓使用者點擊特定技能卡片後,能進入專屬的技能儀表板,切換檢視技能概覽、學習筆記與練習日誌。

1.架構設計:ServerComponent+ClientTabs混合架構
為了兼顧SEO/首屏渲染速度(FCP)與前端豐富的頁籤互動,我們採用分層設計:
2.實戰步驟1:建立單一技能API端點(app/api/skills/[id]/route.ts)
在建置前端頁面前,先確保後端提供完整的單一技能查詢API:

// app/api/skills/[id]/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  try {
    const { id } = params;

    const skill = await prisma.skill.findUnique({
      where: { id },
      include: {
        notes: { orderBy: { createdAt: 'desc' } },
        logs: { orderBy: { date: 'desc' } },
      },
    });

    if (!skill) {
      return NextResponse.json({ error: '找不到該技能' }, { status: 404 });
    }

    return NextResponse.json(skill);
  } catch (error) {
    return NextResponse.json(
      { error: '伺服器取得技能資料失敗' },
      { status: 500 }
    );
  }
}

3.實戰步驟2:封裝Client端頁籤互動組件(components/skills/SkillDetailView.tsx)
這個ClientComponent負責處理使用者介面的互動,包括Tabs切換、動態進度條顯示以及刪除/編輯Modal的觸發。

// components/skills/SkillDetailView.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { BookOpen, Calendar, ArrowLeft, Trash2, Trophy } from 'lucide-react';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { useDeleteSkill } from '@/hooks/useSkills';

interface SkillDetailViewProps {
  skill: {
    id: string;
    title: string;
    category: string;
    proficiency: number;
    description: string | null;
    createdAt: string;
  };
}

export function SkillDetailView({ skill }: SkillDetailViewProps) {
  const router = useRouter();
  const [activeTab, setActiveTab] = useState<'overview' | 'notes' | 'logs'>('overview');
  const [isDeleteOpen, setIsDeleteOpen] = useState(false);
  const deleteSkillMutation = useDeleteSkill();

  const handleDelete = () => {
    deleteSkillMutation.mutate(skill.id, {
      onSuccess: () => {
        setIsDeleteOpen(false);
        router.push('/skills');
      },
    });
  };

  return (
    <div className="max-w-5xl mx-auto px-4 py-8 space-y-8">
      {/* 頂部導覽列 */}
      <div className="flex items-center justify-between">
        <button
          onClick={() => router.back()}
          className="flex items-center gap-2 text-sm text-slate-400 hover:text-slate-100 transition"
        >
          <ArrowLeft className="h-4 w-4" /> 返回技能列表
        </button>

        <button
          onClick={() => setIsDeleteOpen(true)}
          className="flex items-center gap-2 px-3 py-1.5 text-sm text-rose-400 border border-rose-500/20 bg-rose-500/10 rounded-lg hover:bg-rose-500/20 transition"
        >
          <Trash2 className="h-4 w-4" /> 刪除技能
        </button>
      </div>

      {/* Hero 技能資訊面板 */}
      <div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-6 md:p-8 backdrop-blur-md space-y-6">
        <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
          <div>
            <span className="inline-block px-3 py-1 text-xs font-semibold rounded-full bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
              {skill.category}
            </span>
            <h1 className="text-3xl font-extrabold text-slate-100 mt-2">{skill.title}</h1>
          </div>

          <div className="flex items-center gap-3 bg-slate-800/80 px-4 py-3 rounded-xl border border-slate-700/60">
            <Trophy className="h-6 w-6 text-amber-400" />
            <div>
              <div className="text-xs text-slate-400">當前熟練度</div>
              <div className="text-xl font-bold text-slate-100">{skill.proficiency} %</div>
            </div>
          </div>
        </div>

        {/* 熟練度進度條 */}
        <div className="w-full bg-slate-800 h-3 rounded-full overflow-hidden">
          <div
            className="bg-gradient-to-r from-cyan-500 to-blue-500 h-full transition-all duration-500"
            style={{ width: `${skill.proficiency}%` }}
          />
        </div>

        <p className="text-slate-300 text-sm leading-relaxed">
          {skill.description || '尚無描述內容...'}
        </p>
      </div>

      {/* 頁籤導覽 (Tabs) */}
      <div className="border-b border-slate-800 flex gap-6">
        {[
          { key: 'overview', label: '總覽', icon: BookOpen },
          { key: 'notes', label: '學習筆記', icon: BookOpen },
          { key: 'logs', label: '練習紀錄', icon: Calendar },
        ].map((tab) => {
          const Icon = tab.icon;
          const isActive = activeTab === tab.key;
          return (
            <button
              key={tab.key}
              onClick={() => setActiveTab(tab.key as any)}
              className={`flex items-center gap-2 pb-3 font-medium text-sm border-b-2 transition ${
                isActive
                  ? 'border-cyan-500 text-cyan-400'
                  : 'border-transparent text-slate-400 hover:text-slate-200'
              }`}
            >
              <Icon className="h-4 w-4" />
              {tab.label}
            </button>
          );
        })}
      </div>

      {/* 頁籤內容展現區域 */}
      <div className="pt-2">
        {activeTab === 'overview' && (
          <div className="rounded-xl border border-slate-800 bg-slate-900/40 p-6 text-slate-300 text-sm">
            這裡展示技能的總體統計數據與近期的練習趨勢圖表。
          </div>
        )}
        {activeTab === 'notes' && (
          <div className="text-slate-400 text-sm">學習筆記模組(將於 Day 21 實作)</div>
        )}
        {activeTab === 'logs' && (
          <div className="text-slate-400 text-sm">練習日誌模組(將於 Day 22 實作)</div>
        )}
      </div>

      {/* 刪除確認 Modal */}
      <ConfirmDialog
        isOpen={isDeleteOpen}
        onClose={() => setIsDeleteOpen(false)}
        onConfirm={handleDelete}
        title={`確定要刪除「${skill.title}」嗎?`}
        message="此操作無法復原,與該技能相關的所有筆記與練習日誌將一併清除。"
        isLoading={deleteSkillMutation.isPending}
      />
    </div>
  );
}

4.實戰步驟3:撰寫ServerComponent主頁面(app/skills/[id]/page.tsx)
現在我們可以在page.tsx中使用Next.js的原生Server-Side擷取機制,預先將數據載入完成,若技能不存在則自動呼叫notFound():

// app/skills/[id]/page.tsx
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import { SkillDetailView } from '@/components/skills/SkillDetailView';

interface SkillPageProps {
  params: {
    id: string;
  };
}

export async function generateMetadata({ params }: SkillPageProps) {
  const skill = await prisma.skill.findUnique({ where: { id: params.id } });
  if (!skill) return { title: '技能不存在 | VibePulse' };
  return { title: `${skill.title} | VibePulse 技能儀表板` };
}

export default async function SkillDetailPage({ params }: SkillPageProps) {
  const skill = await prisma.skill.findUnique({
    where: { id: params.id },
  });

  if (!skill) {
    notFound();
  }

  // 序列化 Date 物件傳遞給 Client Component
  const formattedSkill = {
    ...skill,
    createdAt: skill.createdAt.toISOString(),
  };

  return <SkillDetailView skill={formattedSkill} />;
}

今天我們完成了VibePulse的動態路由頁面建置:

/skills/[id]動態路由:靈活運用Next.jsAppRouter的動態參數結構。

RSC+ClientComponent混合架構:Server端預先處理404與資料庫SSR,Client端負責頁籤互動與Modal防護。

極致介面排版:建立充滿科技質感的Hero區域、Progress進度條與風格一致的Tabs切換器。


上一篇
Day 19|全站UI體驗升級Sonner Toast通知系統、Modal對話框與Confirm Dialog封裝
下一篇
Day 21|Markdown學習筆記系統實作CRUD與富文本即時預覽
系列文
Vibe Coding的30天,自然語言與AI共舞,從Prompt到高品質原型落地22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言